import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Star, RotateCw, Plus, ArrowLeft, Users, Shield, Trash2, Edit3, Copy, Camera, Image as ImageIcon, Lock, Eye, EyeOff, Calculator, X, Globe, User, LogOut, Coins, Check, PiggyBank, AlertTriangle, Sparkles, Search, Calendar, Activity, Wallet, PieChart, Unlock, Dice5, ChevronRight } from 'lucide-react'; import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js"; import { getAuth, signInAnonymously, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js"; import { getFirestore, doc, setDoc, getDoc, collection, onSnapshot, updateDoc, deleteDoc } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js"; const localFirebaseConfig = { apiKey: "AIzaSyCQiCaR4vSSSCm4eDnAmqPHEVo0SOu8gL8", authDomain: "wuru-bills.firebaseapp.com", projectId: "wuru-bills", storageBucket: "wuru-bills.firebasestorage.app", messagingSenderId: "484804881566", appId: "1:484804881566:web:7fa2c4ad521a2ec596df8f", measurementId: "G-PYCZ08EQRN" }; const appId = 'wuru-bills-app'; const firebaseApp = initializeApp(localFirebaseConfig); const auth = getAuth(firebaseApp); const db = getFirestore(firebaseApp); const DRIVE_GAS_URL = 'https://script.google.com/macros/s/AKfycbwPmYPAKsOsPW4Qhcx8D9T_qILr1glY-DzsK9fbz-q8gBkBNALW9tvrWNmOoBefLg8T-g/exec'; const ADMIN_USERNAMES = ['admin', 'maywuru']; const AVATAR_LIST = Array.from({ length: 40 }, (_, i) => `F${i + 1}.png`); export default function App() { const [currentUser, setCurrentUser] = useState(null); const [currentScreen, setCurrentScreen] = useState('login'); // 'login', 'dashboard', 'bill-detail', 'members' // Real-time caches from Firestore const [cacheUsers, setCacheUsers] = useState([]); const [cacheBills, setCacheBills] = useState([]); const [cacheTransactions, setCacheTransactions] = useState([]); const [cachePermissions, setCachePermissions] = useState([]); // App-wide loading, toast and custom confirm dialog states const [isLoading, setIsLoading] = useState(false); const [loadingText, setLoadingText] = useState('กำลังโหลด...'); const [toast, setToast] = useState({ message: '', type: 'success', visible: false }); const [confirmDialog, setConfirmDialog] = useState({ visible: false, title: '', message: '', onConfirm: null }); // Search & Filter controls const [searchBillsQuery, setSearchBillsQuery] = useState(''); const [sortBillsBy, setSortBillsBy] = useState('newest'); const [filterOldTrips, setFilterOldTrips] = useState(false); const [searchMembersQuery, setSearchMembersQuery] = useState(''); // Navigation / Details states const [currentActiveBillId, setCurrentActiveBillId] = useState(null); const [currentActiveBillRole, setCurrentActiveBillRole] = useState('Viewer'); const [billTab, setBillTab] = useState('tx'); // 'tx' or 'summary' // Login input states const [usernameInput, setUsernameInput] = useState(''); const [passwordInput, setPasswordInput] = useState(''); const [showLoginPassword, setShowLoginPassword] = useState(false); const [isCalcOpen, setIsCalcOpen] = useState(false); const [calcExpression, setCalcExpression] = useState(''); const [calcCurrentInput, setCalcCurrentInput] = useState('0'); const [calcJustEvaluated, setCalcJustEvaluated] = useState(false); // Modal control states const [isBillModalOpen, setIsBillModalOpen] = useState(false); const [isTxModalOpen, setIsTxModalOpen] = useState(false); const [isMemberModalOpen, setIsMemberModalOpen] = useState(false); const [isPermissionModalOpen, setIsPermissionModalOpen] = useState(false); const [isUserPermissionModalOpen, setIsUserPermissionModalOpen] = useState(false); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); // Form states const [billForm, setBillForm] = useState({ id: '', name: '', start: '', end: '', status: 'เปิดอยู่', currency1: 'THB', exchangeRate1: 1, currency2: 'NONE', exchangeRate2: 0, currency3: 'NONE', exchangeRate3: 0, selectedMembers: [], searchQuery: '' }); const [txForm, setTxForm] = useState({ id: '', description: '', spentCurrency: 'THB', foreignAmount: '', exchangeRate: 1, totalAmount: '', date: '', paidBy: '', splitType: 'หารเท่า', splitTarget: '', splitRatios: {}, // { memberName: ratio } selectedFile: null, fileName: '', existingImg: '', existingFileId: '' }); const [memberForm, setMemberForm] = useState({ originalName: '', displayName: '', avatar: 'F1.png', allowLogin: 'No', username: '', password: '' }); const [selectedUserPermissionName, setSelectedUserPermissionName] = useState(''); const [profileForm, setProfileForm] = useState({ avatar: 'F1.png', newPassword: '', showPassword: false }); useEffect(() => { setIsLoading(true); setLoadingText('กำลังเชื่อมต่อคลาวด์ความเร็วสูง...'); // Auto-login from localStorage if exists const savedUser = localStorage.getItem('splitbill_user'); if (savedUser) { setCurrentUser(JSON.parse(savedUser)); } let authUnsubscribe; let usersUnsubscribe; let permissionsUnsubscribe; let billsUnsubscribe; let transactionsUnsubscribe; const setupSync = () => { const getColRef = (colName) => collection(db, 'artifacts', appId, 'public', 'data', colName); usersUnsubscribe = onSnapshot(getColRef('Users'), (snap) => { const list = snap.docs.map(d => d.data()); setCacheUsers(list); }); permissionsUnsubscribe = onSnapshot(getColRef('Permissions'), (snap) => { setCachePermissions(snap.docs.map(d => d.data())); }); billsUnsubscribe = onSnapshot(getColRef('Bills'), (snap) => { const list = snap.docs.map(d => d.data()); setCacheBills(list); }); transactionsUnsubscribe = onSnapshot(getColRef('Transactions'), (snap) => { setCacheTransactions(snap.docs.map(d => d.data())); }); setIsLoading(false); }; authUnsubscribe = onAuthStateChanged(auth, async (user) => { if (user) { setupSync(); // Handle direct share link if URL query parameters exist const urlParams = new URLSearchParams(window.location.search); const publicBillId = urlParams.get('billId'); const publicKey = urlParams.get('key'); if (publicBillId && publicKey) { const guestUser = { username: 'guest', displayName: 'Guest', role: 'Viewer' }; setCurrentUser(guestUser); setCurrentActiveBillId(publicBillId); setCurrentActiveBillRole('Viewer'); setCurrentScreen('bill-detail'); } else if (localStorage.getItem('splitbill_user')) { setCurrentScreen('dashboard'); } else { setCurrentScreen('login'); } } else { signInAnonymously(auth).catch(err => { showToast('การยืนยันสิทธิ์ล้มเหลว: ' + err.message, 'error'); }); } }); return () => { if (authUnsubscribe) authUnsubscribe(); if (usersUnsubscribe) usersUnsubscribe(); if (permissionsUnsubscribe) permissionsUnsubscribe(); if (billsUnsubscribe) billsUnsubscribe(); if (transactionsUnsubscribe) transactionsUnsubscribe(); }; }, []); const dbCurrentUserObj = useMemo(() => { if (!currentUser) return null; return cacheUsers.find(u => (u.Username && u.Username.toLowerCase() === currentUser.username.toLowerCase()) || (u.Display_Name === currentUser.displayName) ); }, [currentUser, cacheUsers]); const userAvatarUrl = dbCurrentUserObj?.Avatar || 'F1.png'; const showToast = (message, type = 'success') => { setToast({ message, type, visible: true }); setTimeout(() => { setToast(prev => ({ ...prev, visible: false })); }, 3000); }; const triggerConfirm = (title, message, onConfirm) => { setConfirmDialog({ visible: true, title, message, onConfirm }); }; const handleLoginSubmit = async (e) => { e.preventDefault(); if (!usernameInput || !passwordInput) { showToast('กรุณากรอกข้อมูลให้ครบถ้วนนะค้า', 'error'); return; } setIsLoading(true); setLoadingText('กำลังตรวจสอบข้อมูลผู้ใช้...'); try { const userObj = cacheUsers.find(x => (x.Username && x.Username.toLowerCase() === usernameInput.trim().toLowerCase()) || (x.Display_Name && x.Display_Name.toLowerCase() === usernameInput.trim().toLowerCase()) ); if (!userObj) throw new Error('ไม่พบชื่อผู้ใช้งานนี้ในระบบ'); if (userObj.Allow_Login !== 'Yes') throw new Error('บัญชีนี้ยังไม่ได้รับอนุญาตให้ล็อกอินน้า'); if (userObj.Status === 'Locked') throw new Error('บัญชีถูกล็อกเนื่องจากรหัสผ่านผิดเกิน 5 ครั้ง'); const userDocRef = doc(db, 'artifacts', appId, 'public', 'data', 'Users', userObj.Display_Name); if (userObj.Password === passwordInput) { await updateDoc(userDocRef, { Failed_Attempts: 0 }); const isAdmin = (userObj.Username && ADMIN_USERNAMES.includes(userObj.Username.toLowerCase())) || userObj.Display_Name === 'เม'; const loggedInUser = { username: userObj.Username, displayName: userObj.Display_Name, isAdmin: isAdmin }; setCurrentUser(loggedInUser); localStorage.setItem('splitbill_user', JSON.stringify(loggedInUser)); setUsernameInput(''); setPasswordInput(''); setCurrentScreen('dashboard'); showToast(`ยินดีต้อนรับ ${userObj.Display_Name}! 🐹`, 'success'); } else { const fails = (parseInt(userObj.Failed_Attempts) || 0) + 1; if (fails >= 5) { await updateDoc(userDocRef, { Failed_Attempts: fails, Status: 'Locked' }); throw new Error('บัญชีถูกล็อกแล้ว เนื่องจากใส่รหัสผิดครบ 5 ครั้งจ้า'); } else { await updateDoc(userDocRef, { Failed_Attempts: fails }); throw new Error(`รหัสผ่านไม่ถูกต้อง! (ผิดไปแล้ว ${fails}/5 ครั้ง)`); } } } catch (e) { showToast(e.message, 'error'); } finally { setIsLoading(false); } }; const handleLogout = () => { triggerConfirm('ออกจากระบบ', 'คุณต้องการจะออกจากระบบบิลใช่หรือไม่จ๊ะ? 🥺', () => { localStorage.removeItem('splitbill_user'); setCurrentUser(null); setCurrentScreen('login'); showToast('ออกจากระบบสำเร็จแล้วค่ะ'); }); }; const allowedBills = useMemo(() => { if (!currentUser) return []; if (currentUser.isAdmin || currentUser.displayName === 'เม' || currentUser.username === 'maywuru') { return cacheBills; } return cacheBills.filter(bill => { const perm = cachePermissions.find(p => p.Bill_ID === bill.Bill_ID && p.Username === currentUser.username); if (perm && perm.Can_See === 'No') return false; if (perm && perm.Can_See === 'Yes') return true; const members = bill.Members ? bill.Members.split(',').map(m => m.trim()) : []; return members.includes(currentUser.displayName); }); }, [currentUser, cacheBills, cachePermissions]); const sortedAndFilteredBills = useMemo(() => { let result = [...allowedBills]; // Filter old cleared trips for admins if (filterOldTrips && currentUser?.isAdmin) { const today = new Date(); result = result.filter(b => { const isCleared = b.Status === 'เคลียร์แล้ว' || b.Status === 'สำเร็จแล้ว'; if (!isCleared) return false; if (!b.Start_Date) return false; const tripDate = new Date(b.Start_Date); const diffTime = Math.abs(today - tripDate); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return diffDays > 150; // 5 months approx }); } // Search query filter if (searchBillsQuery.trim()) { result = result.filter(b => b.Bill_Name && b.Bill_Name.toLowerCase().includes(searchBillsQuery.toLowerCase())); } // Sorting result.sort((a, b) => { if (sortBillsBy === 'newest') { return new Date(b.Start_Date || 0) - new Date(a.Start_Date || 0); } else if (sortBillsBy === 'oldest') { return new Date(a.Start_Date || 0) - new Date(b.Start_Date || 0); } else if (sortBillsBy === 'name_asc') { return (a.Bill_Name || '').localeCompare(b.Bill_Name || '', 'th'); } else if (sortBillsBy === 'name_desc') { return (b.Bill_Name || '').localeCompare(a.Bill_Name || '', 'th'); } return 0; }); return result; }, [allowedBills, filterOldTrips, searchBillsQuery, sortBillsBy, currentUser]); const currentActiveBillObj = useMemo(() => { return cacheBills.find(b => b.Bill_ID === currentActiveBillId) || null; }, [currentActiveBillId, cacheBills]); const handleCurrencyFieldChange = async (num, currencyCode) => { setBillForm(prev => { const next = { ...prev }; next[`currency${num}`] = currencyCode; if (currencyCode === 'THB') { next[`exchangeRate${num}`] = 1; } else if (currencyCode === 'NONE') { next[`exchangeRate${num}`] = 0; } return next; }); if (currencyCode !== 'THB' && currencyCode !== 'NONE') { await fetchRateForBillForm(num, currencyCode); } }; const fetchRateForBillForm = async (num, code) => { setIsLoading(true); setLoadingText('กำลังดึงเรทค่าเงินกลางเรียลไทม์...'); try { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/${code.toLowerCase()}.json`); if (!res.ok) throw new Error('API Error'); const data = await res.json(); const rate = data[code.toLowerCase()]['thb']; setBillForm(prev => ({ ...prev, [`exchangeRate${num}`]: parseFloat(rate).toFixed(5) })); showToast('ดึงเรทอัตราแลกเปลี่ยนปัจจุบันสำเร็จ ⚡'); } catch (e) { showToast('ไม่สามารถเชื่อมต่อดึงเรทเงินได้ กรุณากรอกเองนะค้า', 'error'); } finally { setIsLoading(false); } }; const triggerOpenBillModal = (existingId = '') => { if (existingId) { const bill = cacheBills.find(b => b.Bill_ID === existingId); if (bill) { setBillForm({ id: bill.Bill_ID, name: bill.Bill_Name || '', start: bill.Start_Date || '', end: bill.End_Date || '', status: bill.Status || 'เปิดอยู่', currency1: bill.Currency_1 || bill.Currency || 'THB', exchangeRate1: bill.Exchange_Rate_1 || bill.Exchange_Rate || 1, currency2: bill.Currency_2 || 'NONE', exchangeRate2: bill.Exchange_Rate_2 || 0, currency3: bill.Currency_3 || 'NONE', exchangeRate3: bill.Exchange_Rate_3 || 0, selectedMembers: bill.Members ? bill.Members.split(',').map(m => m.trim()) : [], searchQuery: '' }); } } else { setBillForm({ id: '', name: '', start: new Date().toISOString().split('T')[0], end: '', status: 'เปิดอยู่', currency1: 'THB', exchangeRate1: 1, currency2: 'NONE', exchangeRate2: 0, currency3: 'NONE', exchangeRate3: 0, selectedMembers: [], searchQuery: '' }); } setIsBillModalOpen(true); }; const handleSaveBill = async (e) => { e.preventDefault(); if (!billForm.name || !billForm.start) { showToast('กรุณากรอกชื่อทริปและวันที่เริ่มนะค้า', 'error'); return; } if (billForm.selectedMembers.length === 0) { showToast('กรุณาเลือกเพื่อนในทริปอย่างน้อย 1 คนน้า', 'error'); return; } setIsLoading(true); setLoadingText('กำลังบันทึกข้อมูลทริปไปยังคลาวด์...'); try { const membersText = billForm.selectedMembers.join(', '); const bData = { Bill_ID: billForm.id || 'B' + Date.now().toString().slice(-6), Bill_Name: billForm.name, Start_Date: billForm.start, End_Date: billForm.end || '', Status: billForm.status, Members: membersText, Currency_1: billForm.currency1, Exchange_Rate_1: parseFloat(billForm.exchangeRate1) || 1, Currency_2: billForm.currency2, Exchange_Rate_2: parseFloat(billForm.exchangeRate2) || 0, Currency_3: billForm.currency3, Exchange_Rate_3: parseFloat(billForm.exchangeRate3) || 0, Currency: billForm.currency1, // Backwards compatibility Exchange_Rate: parseFloat(billForm.exchangeRate1) || 1, }; if (!billForm.id) { bData.Share_Key = ''; } await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Bills', bData.Bill_ID), bData, { merge: true }); showToast(billForm.id ? 'แก้ไขข้อมูลทริปเรียบร้อย 💖' : 'สร้างทริปสำเร็จและเปิดเรียลไทม์แล้ว 💖'); setIsBillModalOpen(false); } catch (e) { showToast('เกิดข้อผิดพลาด: ' + e.message, 'error'); } finally { setIsLoading(false); } }; const activeBillTransactions = useMemo(() => { return cacheTransactions.filter(t => t.Bill_ID === currentActiveBillId); }, [cacheTransactions, currentActiveBillId]); const activeBillMembersList = useMemo(() => { if (!currentActiveBillObj) return []; return currentActiveBillObj.Members ? currentActiveBillObj.Members.split(',').map(m => m.trim()).filter(m => m) : []; }, [currentActiveBillObj]); const debtSettlementData = useMemo(() => { if (!currentActiveBillObj || activeBillMembersList.length === 0) return { totalTrip: 0, avgPerPerson: 0, settlements: [], memberStats: [] }; let paidTotal = {}; let shareTotal = {}; let netBalance = {}; activeBillMembersList.forEach(m => { paidTotal[m] = 0; shareTotal[m] = 0; netBalance[m] = 0; }); activeBillTransactions.forEach(tx => { const amt = parseFloat(tx.Total_Amount) || 0; const payer = tx.Paid_By; if (paidTotal[payer] !== undefined) { paidTotal[payer] += amt; } if (tx.Split_Type === 'หารเท่า') { const splitAmt = amt / activeBillMembersList.length; activeBillMembersList.forEach(m => { if (shareTotal[m] !== undefined) shareTotal[m] += splitAmt; }); } else if (tx.Split_Type === 'จ่ายเต็มจำนวน') { const target = tx.Split_Detail?.trim(); if (shareTotal[target] !== undefined) { shareTotal[target] += amt; } } else if (tx.Split_Type === 'กำหนดสัดส่วน') { const parts = tx.Split_Detail ? tx.Split_Detail.split(',') : []; let totalParts = 0; let parsedParts = {}; parts.forEach(p => { const [name, part] = p.split(':'); if (name && part) { const n = name.trim(); const pt = parseFloat(part); parsedParts[n] = pt; totalParts += pt; } }); if (totalParts > 0) { for (let name in parsedParts) { if (shareTotal[name] !== undefined) { shareTotal[name] += (parsedParts[name] / totalParts) * amt; } } } } }); let debtors = []; let creditors = []; activeBillMembersList.forEach(m => { netBalance[m] = paidTotal[m] - shareTotal[m]; const val = Math.round(netBalance[m] * 100) / 100; if (val > 0.05) { creditors.push({ name: m, amount: val }); } else if (val < -0.05) { debtors.push({ name: m, amount: Math.abs(val) }); } }); debtors.sort((a, b) => b.amount - a.amount); creditors.sort((a, b) => b.amount - a.amount); let settlements = []; let i = 0, j = 0; const debtTemp = debtors.map(d => ({ ...d })); const credTemp = creditors.map(c => ({ ...c })); while (i < debtTemp.length && j < credTemp.length) { let d = debtTemp[i]; let c = credTemp[j]; let amountToSettle = Math.min(d.amount, c.amount); if (amountToSettle > 0.01) { settlements.push({ from: d.name, to: c.name, amount: amountToSettle }); d.amount -= amountToSettle; c.amount -= amountToSettle; } if (d.amount < 0.05) i++; if (c.amount < 0.05) j++; } const totalTrip = activeBillTransactions.reduce((sum, t) => sum + (parseFloat(t.Total_Amount) || 0), 0); const avgPerPerson = totalTrip / activeBillMembersList.length; const memberStats = activeBillMembersList.map(m => ({ name: m, paid: paidTotal[m], share: shareTotal[m], net: netBalance[m] })); return { totalTrip, avgPerPerson, settlements, memberStats }; }, [activeBillTransactions, activeBillMembersList, currentActiveBillObj]); const handleTxCurrencyChange = (currencyCode) => { let rate = 1; if (currencyCode !== 'THB') { const activeTrip = currentActiveBillObj; if (activeTrip) { if (activeTrip.Currency_1 === currencyCode) rate = activeTrip.Exchange_Rate_1 || 1; else if (activeTrip.Currency_2 === currencyCode) rate = activeTrip.Exchange_Rate_2 || 1; else if (activeTrip.Currency_3 === currencyCode) rate = activeTrip.Exchange_Rate_3 || 1; } } setTxForm(prev => { const updated = { ...prev, spentCurrency: currencyCode, exchangeRate: rate }; if (currencyCode === 'THB') { updated.foreignAmount = ''; updated.exchangeRate = 1; } else { if (updated.foreignAmount) { updated.totalAmount = (parseFloat(updated.foreignAmount) * rate).toFixed(2); } } return updated; }); }; const handleTxForeignAmountChange = (val) => { setTxForm(prev => { const updated = { ...prev, foreignAmount: val }; if (prev.spentCurrency !== 'THB' && val !== '') { updated.totalAmount = (parseFloat(val) * parseFloat(prev.exchangeRate)).toFixed(2); } return updated; }); }; const handleTxExchangeRateChange = (rateVal) => { setTxForm(prev => { const updated = { ...prev, exchangeRate: rateVal }; if (prev.spentCurrency !== 'THB' && prev.foreignAmount !== '') { updated.totalAmount = (parseFloat(prev.foreignAmount) * parseFloat(rateVal)).toFixed(2); } return updated; }); }; const triggerOpenTxModal = (existingTxId = '') => { if (!currentActiveBillObj) return; const members = activeBillMembersList; const defaultPayer = currentUser && members.includes(currentUser.displayName) ? currentUser.displayName : members[0] || ''; // Determine available currencies for this trip let currencies = []; const activeTrip = currentActiveBillObj; if (activeTrip.Currency_1) currencies.push({ code: activeTrip.Currency_1, rate: activeTrip.Exchange_Rate_1 }); else if (activeTrip.Currency) currencies.push({ code: activeTrip.Currency, rate: activeTrip.Exchange_Rate }); else currencies.push({ code: 'THB', rate: 1 }); if (activeTrip.Currency_2 && activeTrip.Currency_2 !== 'NONE') { currencies.push({ code: activeTrip.Currency_2, rate: activeTrip.Exchange_Rate_2 }); } if (activeTrip.Currency_3 && activeTrip.Currency_3 !== 'NONE') { currencies.push({ code: activeTrip.Currency_3, rate: activeTrip.Exchange_Rate_3 }); } if (!currencies.some(c => c.code === 'THB')) { currencies.unshift({ code: 'THB', rate: 1 }); } if (existingTxId) { const tx = cacheTransactions.find(t => t.Transaction_ID === existingTxId); if (tx) { let initialRatios = {}; if (tx.Split_Type === 'กำหนดสัดส่วน') { const parts = tx.Split_Detail ? tx.Split_Detail.split(',') : []; parts.forEach(p => { const [name, val] = p.split(':'); if (name && val) initialRatios[name.trim()] = val.trim(); }); } setTxForm({ id: tx.Transaction_ID, description: tx.Description || '', spentCurrency: tx.Spent_Currency || 'THB', foreignAmount: tx.Foreign_Amount || '', exchangeRate: tx.Exchange_Rate || 1, totalAmount: tx.Total_Amount || '', date: tx.Transaction_Date || '', paidBy: tx.Paid_By || defaultPayer, splitType: tx.Split_Type || 'หารเท่า', splitTarget: tx.Split_Type === 'จ่ายเต็มจำนวน' ? tx.Split_Detail : (members[0] || ''), splitRatios: initialRatios, selectedFile: null, fileName: '', existingImg: tx.Image_URL || '', existingFileId: tx.File_ID || '' }); } } else { setTxForm({ id: '', description: '', spentCurrency: 'THB', foreignAmount: '', exchangeRate: 1, totalAmount: '', date: new Date().toISOString().split('T')[0], paidBy: defaultPayer, splitType: 'หารเท่า', splitTarget: members[0] || '', splitRatios: members.reduce((acc, name) => ({ ...acc, [name]: '1' }), {}), selectedFile: null, fileName: '', existingImg: '', existingFileId: '' }); } setIsTxModalOpen(true); }; const handleTxFileSelect = (e) => { const file = e.target.files[0]; if (file) { setTxForm(prev => ({ ...prev, selectedFile: file, fileName: file.name })); } }; const handleSaveTransaction = async (e) => { e.preventDefault(); if (!txForm.description || !txForm.totalAmount) { showToast('กรุณากรอกหัวข้อรายการและจำนวนเงินบาทนะค้า', 'error'); return; } setIsLoading(true); setLoadingText('กำลังอัปโหลดข้อมูลและบันทึกบิลย่อย...'); try { let finalImgUrl = txForm.existingImg; let finalFileId = txForm.existingFileId; if (txForm.selectedFile) { const base64 = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(txForm.selectedFile); }); if (txForm.id && txForm.existingFileId) { await fetch(DRIVE_GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'deleteImage', fileId: txForm.existingFileId }) }).catch(() => {}); } const tempIdForFile = txForm.id || 'T' + Date.now().toString().slice(-6); const res = await fetch(DRIVE_GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'uploadImage', base64, filename: `receipt_${tempIdForFile}.jpg` }) }); const uploadRes = await res.json(); if (!uploadRes.success) throw new Error(uploadRes.error); finalImgUrl = uploadRes.data.url; finalFileId = uploadRes.data.fileId; } let detail = 'ทุกคน'; if (txForm.splitType === 'จ่ายเต็มจำนวน') { detail = txForm.splitTarget; } else if (txForm.splitType === 'กำหนดสัดส่วน') { const parts = []; for (let m in txForm.splitRatios) { const val = parseFloat(txForm.splitRatios[m]) || 0; if (val > 0) parts.push(`${m}:${val}`); } detail = parts.join(', '); if (!detail) throw new Error('ต้องมีเพื่อนร่วมแชร์ค่าใช้จ่ายอย่างน้อยหนึ่งคนนะค้า'); } const txId = txForm.id || 'T' + Date.now().toString().slice(-6); const transactionData = { Transaction_ID: txId, Bill_ID: currentActiveBillId, Transaction_Date: txForm.date, Description: txForm.description, Total_Amount: parseFloat(txForm.totalAmount) || 0, Foreign_Amount: txForm.spentCurrency !== 'THB' ? parseFloat(txForm.foreignAmount) || 0 : 0, Exchange_Rate: txForm.spentCurrency !== 'THB' ? parseFloat(txForm.exchangeRate) || 1 : 1, Spent_Currency: txForm.spentCurrency, Paid_By: txForm.paidBy, Split_Type: txForm.splitType, Split_Detail: detail, Image_URL: finalImgUrl, File_ID: finalFileId }; await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Transactions', txId), transactionData, { merge: true }); showToast(txForm.id ? 'แก้ไขรายการจ่ายสำเร็จแล้วค่ะ ✏️' : 'บันทึกรายการจ่ายเรียบร้อย! ⚡'); setIsTxModalOpen(false); } catch (e) { showToast(e.message, 'error'); } finally { setIsLoading(false); } }; const handleDeleteTransaction = (txId, fileId) => { triggerConfirm('ลบรายการใช้จ่าย', 'ยืนยันที่จะลบรายการนี้นะค้า? (สลิปเดิมในระบบ Google Drive จะถูกย้ายไปถังขยะอัตโนมัติด้วย)', async () => { setIsLoading(true); setLoadingText('กำลังนำรายการออกจากระบบ...'); try { if (fileId && fileId !== 'null' && fileId !== 'undefined') { await fetch(DRIVE_GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'deleteImage', fileId }) }).catch(() => {}); } await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Transactions', txId)); showToast('ลบรายการใช้จ่ายเรียบร้อย'); } catch (e) { showToast(e.message, 'error'); } finally { setIsLoading(false); } }); }; const handleDeleteCurrentBill = () => { if (!currentActiveBillId) return; triggerConfirm('🚨 ลบข้อมูลทริปถาวร', 'คุณกำลังจะลบทริปนี้ทิ้งอย่างถาวร (รวมถึงรายการจ่ายและสลิปทั้งหมด) ยืนยันหรือไม่?', async () => { setIsLoading(true); setLoadingText('กำลังลบข้อมูลทริปถาวร...'); try { const txsToDelete = cacheTransactions.filter(t => t.Bill_ID === currentActiveBillId); for (let tx of txsToDelete) { if (tx.File_ID) { await fetch(DRIVE_GAS_URL, { method: 'POST', body: JSON.stringify({ action: 'deleteImage', fileId: tx.File_ID }) }).catch(() => {}); } await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Transactions', tx.Transaction_ID)); } const permsToDelete = cachePermissions.filter(p => p.Bill_ID === currentActiveBillId); for (let p of permsToDelete) { await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Permissions', `${p.Bill_ID}_${p.Username}`)); } await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Bills', currentActiveBillId)); setCurrentActiveBillId(null); setCurrentScreen('dashboard'); showToast('ลบทริปและข้อมูลที่เกี่ยวข้องถาวรแล้วค่ะ'); } catch (e) { showToast(e.message, 'error'); } finally { setIsLoading(false); } }); }; const handleGenerateShareLink = async () => { if (!currentActiveBillId) return; setIsLoading(true); setLoadingText('กำลังสร้างลิงก์สำหรับเพื่อนภายนอก...'); try { const key = Math.random().toString(36).substring(2, 8); await updateDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Bills', currentActiveBillId), { Share_Key: key }); showToast('สร้างลิงก์แชร์ส่วนตัวสำเร็จและคัดลอกลงคลิปบอร์ดแล้วจ้า! 🔗'); } catch (e) { showToast('ไม่สามารถสร้างลิงก์แชร์ได้ในขณะนี้', 'error'); } finally { setIsLoading(false); } }; const handleCopyShareLink = (link) => { const el = document.createElement('textarea'); el.value = link; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); showToast('คัดลอกลิงก์แชร์แล้วจ้า! 🔗'); }; const activeBillPermissionsList = useMemo(() => { if (!currentActiveBillObj) return []; const members = currentActiveBillObj.Members ? currentActiveBillObj.Members.split(',').map(m => m.trim()) : []; return cacheUsers.filter(u => { const isSelfAdmin = (u.Display_Name === 'เม' || u.Username === 'maywuru'); if (isSelfAdmin) return true; if (members.includes(u.Display_Name) && u.Allow_Login === 'Yes') return true; return false; }); }, [currentActiveBillObj, cacheUsers]); const [localPermissions, setLocalPermissions] = useState({}); // { username: { canSee, role } } const triggerOpenPermissionModal = () => { const initialPerms = {}; activeBillPermissionsList.forEach(u => { const p = cachePermissions.find(x => x.Bill_ID === currentActiveBillId && x.Username === u.Username) || {}; initialPerms[u.Username] = { canSee: p.Can_See || 'Yes', role: p.Access_Role || 'Self Editor' }; }); setLocalPermissions(initialPerms); setIsPermissionModalOpen(true); }; const handleSavePermissions = async () => { setIsLoading(true); setLoadingText('กำลังบันทึกการตั้งค่าสิทธิ์เข้าถึง...'); try { for (let username in localPermissions) { const docId = `${currentActiveBillId}_${username}`; await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Permissions', docId), { Bill_ID: currentActiveBillId, Username: username, Can_See: localPermissions[username].canSee, Access_Role: localPermissions[username].role }, { merge: true }); } showToast('อัปเดตสิทธิ์รายทริปเรียบร้อยแล้วจ้า 🛡️'); setIsPermissionModalOpen(false); } catch (e) { showToast('ไม่สามารถบันทึกสิทธิ์ได้: ' + e.message, 'error'); } finally { setIsLoading(false); } }; const triggerOpenUserPermissionModal = (displayName) => { const userObj = cacheUsers.find(u => u.Display_Name === displayName); if (!userObj) return; setSelectedUserPermissionName(displayName); const initialPerms = {}; cacheBills.forEach(b => { const p = cachePermissions.find(x => x.Bill_ID === b.Bill_ID && x.Username === userObj.Username) || {}; const isMember = b.Members ? b.Members.split(',').map(m => m.trim()).includes(displayName) : false; initialPerms[b.Bill_ID] = { canSee: p.Can_See || (isMember ? 'Yes' : 'No'), role: p.Access_Role || (isMember ? 'Self Editor' : 'Viewer') }; }); setLocalPermissions(initialPerms); setIsUserPermissionModalOpen(true); }; const handleSaveUserPermissions = async () => { const userObj = cacheUsers.find(u => u.Display_Name === selectedUserPermissionName); if (!userObj) return; setIsLoading(true); setLoadingText('กำลังอัปเดตสิทธิ์ผู้ใช้รายบุคคล...'); try { for (let billId in localPermissions) { const docId = `${billId}_${userObj.Username}`; await setDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Permissions', docId), { Bill_ID: billId, Username: userObj.Username, Can_See: localPermissions[billId].canSee, Access_Role: localPermissions[billId].role }, { merge: true }); } showToast(`อัปเดตสิทธิ์เข้าถึงทั้งหมดของ ${selectedUserPermissionName} สำเร็จ 🛡️`); setIsUserPermissionModalOpen(false); } catch (e) { showToast('เกิดข้อผิดพลาดในการบันทึกสิทธิ์', 'error'); } finally { setIsLoading(false); } }; const handleUnlockUserAccount = (displayName) => { triggerConfirm('ปลดล็อกบัญชีเพื่อน', `คุณต้องการปลดล็อกการเข้าสู่ระบบและรีเซ็ตประวัติสำหรับ "${displayName}" ใช่หรือไม่?`, async () => { setIsLoading(true); setLoadingText('กำลังทำการปลดล็อกบัญชีผู้ใช้...'); try { await updateDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Users', displayName), { Status: 'Active', Failed_Attempts: 0 }); showToast(`ปลดล็อกบัญชีของ "${displayName}" เรียบร้อยแล้วค่ะ! 🔓`); } catch (e) { showToast('ไม่สามารถทำรายการได้ในขณะนี้', 'error'); } finally { setIsLoading(false); } }); }; const triggerOpenMemberModal = (name = '') => { if (name) { const u = cacheUsers.find(x => x.Display_Name === name); if (u) { setMemberForm({ originalName: u.Display_Name, displayName: u.Display_Name, avatar: u.Avatar || 'F1.png', allowLogin: u.Allow_Login || 'No', username: u.Username || '', password: u.Password || '' }); } } else { setMemberForm({ originalName: '', displayName: '', avatar: 'F1.png', allowLogin: 'No', username: '', password: '' }); } setIsMemberModalOpen(true); }; const handleSaveMember = async (e) => { e.preventDefault(); if (!memberForm.displayName) { showToast('กรุณากรอกชื่อเรียกของเพื่อนนะค้า', 'error'); return; } if (memberForm.allowLogin === 'Yes' && (!memberForm.username || !memberForm.password)) { showToast('กรุณากรอก Username และ Password สลับสำหรับบัญชีด้วยค่ะ', 'error'); return; } setIsLoading(true); setLoadingText('กำลังส่งข้อมูลสมาชิกบันทึกไปยังเซิร์ฟเวอร์...'); try { const isEdit = memberForm.originalName !== ''; const docRef = doc(db, 'artifacts', appId, 'public', 'data', 'Users', memberForm.displayName); if (isEdit) { if (memberForm.originalName !== memberForm.displayName) { const duplicate = cacheUsers.find(u => u.Display_Name.toLowerCase() === memberForm.displayName.toLowerCase()); if (duplicate) throw new Error('ชื่อเพื่อนคนนี้ซ้ำกับผู้อื่นในระบบ'); const oldUser = cacheUsers.find(u => u.Display_Name === memberForm.originalName); await setDoc(docRef, { Display_Name: memberForm.displayName, Allow_Login: memberForm.allowLogin, Username: memberForm.username, Password: memberForm.password, Status: oldUser?.Status || 'Active', Failed_Attempts: oldUser?.Failed_Attempts || 0, Avatar: memberForm.avatar }); await deleteDoc(doc(db, 'artifacts', appId, 'public', 'data', 'Users', memberForm.originalName)); if (currentUser?.displayName === memberForm.originalName) { const updatedUser = { ...currentUser, displayName: memberForm.displayName }; setCurrentUser(updatedUser); localStorage.setItem('splitbill_user', JSON.stringify(updatedUser)); } } else { await updateDoc(docRef, { Allow_Login: memberForm.allowLogin, Username: memberForm.username, Password: memberForm.password, Avatar: memberForm.avatar }); } } else { const duplicate = cacheUsers.find(u => u.Display_Name.toLowerCase() === memberForm.displayName.toLowerCase()); if (duplicate) throw new Error('ชื่อเพื่อนคนนี้ซ้ำกับคนในระบบจ้า'); await setDoc(docRef, { Display_Name: memberForm.displayName, Allow_Login: memberForm.allowLogin, Username: memberForm.username, Password: memberForm.password, Status: 'Active', Failed_Attempts: 0, Avatar: memberForm.avatar }); } showToast('บันทึกข้อมูลเพื่อนสำเร็จเรียบร้อยค่ะ 🐾'); setIsMemberModalOpen(false); } catch (e) { showToast(e.message, 'error'); } finally { setIsLoading(false); } }; const triggerOpenProfileModal = () => { if (!dbCurrentUserObj) return; setProfileForm({ avatar: dbCurrentUserObj.Avatar || 'F1.png', newPassword: '', showPassword: false }); setIsProfileModalOpen(true); }; const handleSavePersonalProfile = async (e) => { e.preventDefault(); setIsLoading(true); setLoadingText('กำลังบันทึกประวัติส่วนตัวของคุณ...'); try { const userDocRef = doc(db, 'artifacts', appId, 'public', 'data', 'Users', dbCurrentUserObj.Display_Name); const updates = { Avatar: profileForm.avatar }; if (profileForm.newPassword) { updates.Password = profileForm.newPassword; } await updateDoc(userDocRef, updates); showToast('บันทึกรูปประจำตัวและข้อมูลของคุณเรียบร้อย! 🐹🌟'); setIsProfileModalOpen(false); } catch (e) { showToast('การบันทึกโปรไฟล์ส่วนตัวขัดข้อง', 'error'); } finally { setIsLoading(false); } }; const handleCalcInputPress = (char) => { if (char === 'C') { setCalcExpression(''); setCalcCurrentInput('0'); setCalcJustEvaluated(false); } else if (char === '⌫') { if (calcCurrentInput.length > 1) { setCalcCurrentInput(calcCurrentInput.slice(0, -1)); } else { setCalcCurrentInput('0'); } setCalcJustEvaluated(false); } else if (['+', '-', '*', '/', '%'].includes(char)) { if (calcJustEvaluated) { setCalcExpression(calcCurrentInput + ' ' + char + ' '); setCalcJustEvaluated(false); } else { if (calcCurrentInput !== '0') { setCalcExpression(prev => prev + calcCurrentInput + ' ' + char + ' '); } else if (calcExpression !== '') { setCalcExpression(calcExpression.slice(0, -3) + ' ' + char + ' '); } } setCalcCurrentInput('0'); } else if (char === '=') { let fullExpr = calcExpression + calcCurrentInput; let sanitized = fullExpr.replace(/[^0-9+\-*/%.]/g, ''); try { let result = new Function(`return (${sanitized})`)(); if (result === undefined || isNaN(result) || !isFinite(result)) { setCalcCurrentInput('Error'); } else { setCalcCurrentInput(parseFloat(result.toFixed(2)).toString()); } setCalcExpression(fullExpr + ' ='); setCalcJustEvaluated(true); } catch (e) { setCalcCurrentInput('Error'); setCalcJustEvaluated(true); } } else if (char === '.') { if (!calcCurrentInput.includes('.')) { setCalcCurrentInput(prev => prev + '.'); } setCalcJustEvaluated(false); } else { if (calcCurrentInput === '0' || calcJustEvaluated) { setCalcCurrentInput(char); setCalcJustEvaluated(false); } else { setCalcCurrentInput(prev => prev + char); } } }; const handleApplyCalcResult = () => { if (isTxModalOpen) { const activeElement = document.activeElement; if (activeElement && activeElement.id === 'tx-foreign-amount') { handleTxForeignAmountChange(calcCurrentInput); } else { setTxForm(prev => ({ ...prev, totalAmount: calcCurrentInput })); } showToast(`นำผลลัพธ์ ฿${calcCurrentInput} ไปกรอกเรียบร้อย!`); } setIsCalcOpen(false); }; const filteredMembersList = useMemo(() => { return cacheUsers.filter(u => { if (!u.Display_Name) return false; if (searchMembersQuery.trim()) { return u.Display_Name.toLowerCase().includes(searchMembersQuery.toLowerCase()); } return true; }); }, [cacheUsers, searchMembersQuery]); return (
{/* Background Decorators */}
🐹
🌸
🐰
🐻
🐱
🌸
🌸
🐨
🐼
🌸
🦊
🐨
🌸
{/* Loading Overlay */} {isLoading && (

{loadingText}

)} {/* Custom Toast Alert */} {toast.visible && (
{toast.type === 'error' ? : } {toast.message}
)} {/* Custom Confirm Modal */} {confirmDialog.visible && (

{confirmDialog.title}

{confirmDialog.message}

)} {/* ==================== Header ==================== */} {currentUser && currentScreen !== 'login' && (
App Logo { setCurrentScreen('dashboard'); setCurrentActiveBillId(null); }} onError={(e) => { e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text=WuRu'; }} />
Avatar

{currentUser.displayName}

{currentUser.isAdmin ? 'Admin' : 'Member'}

{currentUser.isAdmin && ( )}
)} {/* ==================== Screen Routing ==================== */}
{/* LOGIN SCREEN */} {currentScreen === 'login' && (
Logo { e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text=WuRu'; }} />

WuRu Bills

✨ Powered by WuRu lab ✨

setUsernameInput(e.target.value)} className="w-full pl-11 pr-4 py-3 border-2 border-[#F6B896]/20 rounded-2xl bg-[#F6B896]/5 focus:bg-white focus:border-[#F6B896] outline-none font-medium text-[#6D5E58] placeholder-[#A89F9A]/60 transition-colors" placeholder="ใส่ชื่อผู้ใช้หรือชื่อเรียกตรงนี้น้า" />
setPasswordInput(e.target.value)} className="w-full pl-11 pr-12 py-3 border-2 border-[#A6C8E0]/20 rounded-2xl bg-[#A6C8E0]/5 focus:bg-white focus:border-[#A6C8E0] outline-none font-medium text-[#6D5E58] placeholder-[#A89F9A]/60 transition-colors" placeholder="รหัสผ่านลับสุดยอด" />
)} {/* DASHBOARD SCREEN */} {currentScreen === 'dashboard' && (

ทริปและบิลของเรา

สรุปยอดที่ต้องเคลียร์ให้เพื่อนๆ

{/* Search and Sort Filter Header */}
setSearchBillsQuery(e.target.value)} className="w-full pl-9 pr-4 py-2.5 border-2 border-gray-100 rounded-xl outline-none text-sm font-medium focus:border-[#A6C8E0] bg-white transition-colors" placeholder="ค้นหาชื่อทริป..." />
{/* Admin toggle filter old trips */} {currentUser?.isAdmin && (
ทริปเก่าเคลียร์แล้ว (> 5 เดือน)
)} {/* Trip Cards Grid */}
{sortedAndFilteredBills.map(b => { const isCleared = b.Status === 'เคลียร์แล้ว' || b.Status === 'สำเร็จแล้ว'; // Fetch dynamic role let myRole = 'Viewer'; if (currentUser.isAdmin || currentUser.displayName === 'เม' || currentUser.username === 'maywuru') { myRole = 'Admin'; } else { const p = cachePermissions.find(x => x.Bill_ID === b.Bill_ID && x.Username === currentUser.username); if (p && p.Access_Role) myRole = p.Access_Role; else if (b.Members && b.Members.split(',').map(m => m.trim()).includes(currentUser.displayName)) { myRole = 'Self Editor'; } } const dateText = b.End_Date ? `${b.Start_Date} - ${b.End_Date}` : b.Start_Date; // Build Multi-currency tags let activeCurrencies = []; if (b.Currency_1) activeCurrencies.push(b.Currency_1); else if (b.Currency) activeCurrencies.push(b.Currency); else activeCurrencies.push('THB'); if (b.Currency_2 && b.Currency_2 !== 'NONE') activeCurrencies.push(b.Currency_2); if (b.Currency_3 && b.Currency_3 !== 'NONE') activeCurrencies.push(b.Currency_3); return (
{ setCurrentActiveBillId(b.Bill_ID); setCurrentActiveBillRole(myRole); setCurrentScreen('bill-detail'); setBillTab('tx'); }} className="bg-white border-2 border-[#A6C8E0]/20 rounded-[24px] p-5 shadow-sm hover:shadow-md hover:scale-[1.01] cursor-pointer active:scale-[0.99] transition-all relative overflow-hidden group" >

{b.Bill_Name}

{dateText}
{activeCurrencies.map(c => ( {c} ))}
{b.Status || 'เปิดอยู่'}
{b.Members ? b.Members.split(',').length : 0} คน สิทธิ์เข้าแก้ไข: {myRole}
); })} {sortedAndFilteredBills.length === 0 && (

ยังไม่มีทริปหรือบิลเลยยย

กดปุ่ม + ด้านล่างเพื่อเปิดบิลใหม่ได้เลยนะ!

)}
{/* FAB Float add bill */} {currentUser?.isAdmin && ( )}
)} {/* BILL DETAILS SCREEN */} {currentScreen === 'bill-detail' && currentActiveBillObj && (
{/* Bill Header Info Card */}

{currentActiveBillObj.Bill_Name} {(currentActiveBillRole === 'Admin' || currentActiveBillRole === 'Full Editor') && ( )}

{currentActiveBillObj.Status || 'เปิดอยู่'}

{currentActiveBillObj.End_Date ? `${currentActiveBillObj.Start_Date} ถึง ${currentActiveBillObj.End_Date}` : currentActiveBillObj.Start_Date}

{currentActiveBillObj.Members}

{/* Admin configuration actions for current bill */} {currentUser?.isAdmin && (
)} {/* Share link box */} {currentActiveBillObj.Share_Key && (

ลิงก์แชร์ของทริปนี้

{`${window.location.origin}${window.location.pathname}?billId=${currentActiveBillObj.Bill_ID}&key=${currentActiveBillObj.Share_Key}`}

)}
{/* View Switching Tab */}
{/* TAB 1: Transactions list view */} {billTab === 'tx' && (
{activeBillTransactions.map(tx => { let canEdit = false; if (currentActiveBillRole === 'Admin' || currentActiveBillRole === 'Full Editor') canEdit = true; else if (currentActiveBillRole === 'Self Editor' && currentUser && tx.Paid_By === currentUser.displayName) canEdit = true; const spentCurrency = tx.Spent_Currency || 'THB'; return (

{tx.Description}

จ่ายโดย: {tx.Paid_By}


หารแบบ: {tx.Split_Type} ({tx.Split_Detail})

฿{parseFloat(tx.Total_Amount).toLocaleString(undefined, { minimumFractionDigits: 2 })}

{spentCurrency !== 'THB' && tx.Foreign_Amount && ( {spentCurrency} {parseFloat(tx.Foreign_Amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} (เรท {tx.Exchange_Rate}) )}

{tx.Transaction_Date || ''}

{tx.Image_URL ? ( ดูรูปสลิป ) : (
)} {canEdit && (
)}
); })} {activeBillTransactions.length === 0 && (
ยังไม่มีรายการใช้จ่าย กดปุ่ม + เพื่อเพิ่มเลย!
)}
)} {/* TAB 2: Debt calculations summaries */} {billTab === 'summary' && (

ยอดรวมทริปนี้

฿{debtSettlementData.totalTrip.toLocaleString(undefined, { minimumFractionDigits: 2 })}

เฉลี่ยคนละ ฿{debtSettlementData.avgPerPerson.toLocaleString(undefined, { minimumFractionDigits: 2 })}

สรุปบัญชีรายคน

{debtSettlementData.memberStats.map(stat => { const friendObj = cacheUsers.find(u => u.Display_Name === stat.name); const friendAvatar = friendObj?.Avatar || 'F1.png'; let netCol = 'text-[#A89F9A]'; let netBg = 'bg-gray-50'; if (stat.net > 0.05) { netCol = 'text-green-600'; netBg = 'bg-green-50'; } else if (stat.net < -0.05) { netCol = 'text-rose-500'; netBg = 'bg-rose-50'; } return (
{stat.name} { e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text='; }} /> {stat.name}
สุทธิ: {stat.net > 0.05 ? '+' : ''}{stat.net.toLocaleString(undefined, { minimumFractionDigits: 2 })}
จ่ายไปก่อน: ฿{stat.paid.toLocaleString(undefined, { minimumFractionDigits: 2 })} ต้องหารจริง: ฿{stat.share.toLocaleString(undefined, { minimumFractionDigits: 2 })}
); })}

สรุปการโอนเงิน

{debtSettlementData.settlements.length === 0 ? (
🎉
เคลียร์ครบจบทุกบิล!
ยอดพอดีเป๊ะ ไม่มีใครติดหนี้ใครแล้วจ้า
) : ( debtSettlementData.settlements.map((s, index) => { const isMeFrom = currentUser && s.from === currentUser.displayName; const isMeTo = currentUser && s.to === currentUser.displayName; const boxClass = (isMeFrom || isMeTo) ? 'bg-white border-[#A6C8E0] shadow-md' : 'bg-white/60 border-white shadow-sm'; const fromColor = isMeFrom ? 'text-rose-500 bg-rose-50' : 'text-[#6D5E58] bg-gray-50'; const toColor = isMeTo ? 'text-green-600 bg-green-50' : 'text-[#6D5E58] bg-gray-50'; const fromUser = cacheUsers.find(u => u.Display_Name === s.from); const toUser = cacheUsers.find(u => u.Display_Name === s.to); const fromAvatar = fromUser?.Avatar || 'F1.png'; const toAvatar = toUser?.Avatar || 'F1.png'; return (
From avatar

{s.from}

จ่ายให้

➡️
To avatar

{s.to}

รับเงิน

฿{s.amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
); }) )}
)} {/* FAB Add Transaction */} {currentActiveBillRole !== 'Viewer' && ( )}
)} {/* MEMBER MANAGEMENT SCREEN (ADMINS ONLY) */} {currentScreen === 'members' && currentUser?.isAdmin && (

จัดการสมาชิก

setSearchMembersQuery(e.target.value)} className="w-full pl-10 pr-4 py-2 border-2 border-gray-100 rounded-xl outline-none text-sm font-medium focus:border-[#A6C8E0] bg-white transition-colors" placeholder="ค้นหาสมาชิก..." />
{filteredMembersList.map(u => { const canLogin = u.Allow_Login === 'Yes'; const avatarImg = u.Avatar || 'F1.png'; const isSelfAdmin = (u.Display_Name === 'เม' || u.Username === 'maywuru' || u.Display_Name === currentUser?.displayName); return (
Avatar icon { e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text='; }} />

{u.Display_Name} {canLogin ? 'เข้าแอปได้' : 'จดชื่อเฉยๆ'}

{canLogin && (

User: {u.Username} | Pass: {u.Password}

)}
{canLogin && ( )} {u.Status === 'Locked' && ( )}
); })}
)}
{/* ==================== Pastels Calculator Popover Panel ==================== */} {isCalcOpen && (
เครื่องคิดเลขพาสเทล
{calcExpression}
{calcCurrentInput}
)} {/* ==================== MODALS ==================== */} {/* 1. BILL MODAL (CREATE / EDIT) */} {isBillModalOpen && (

{billForm.id ? 'แก้ไขข้อมูลทริป' : 'สร้างบิลทริปใหม่'}

setBillForm(prev => ({ ...prev, name: e.target.value }))} className="w-full p-3 border-2 border-[#A6C8E0]/20 rounded-xl focus:border-[#A6C8E0] focus:bg-[#A6C8E0]/5 outline-none font-medium transition-colors text-sm" placeholder="เช่น ทริปหัวหินแสนชิล..." />
{/* Multi-Currency Selection Setup (Up to 3 currencies) */}
สกุลเงินเปิดใช้งานในทริป (สูงสุด 3 สกุลเงิน) {/* Currency 1 */}
setBillForm(prev => ({ ...prev, exchangeRate1: e.target.value }))} readOnly={billForm.currency1 === 'THB'} className="w-full pl-2 pr-7 py-2 border border-blue-200 rounded-xl focus:border-[#A6C8E0] outline-none font-bold text-xs bg-white shadow-sm" placeholder="เรทเทียบ THB" /> {billForm.currency1 !== 'THB' && ( )}
{/* Currency 2 */}
setBillForm(prev => ({ ...prev, exchangeRate2: e.target.value }))} readOnly={billForm.currency2 === 'NONE' || billForm.currency2 === 'THB'} className="w-full pl-2 pr-7 py-2 border border-blue-200 rounded-xl focus:border-[#A6C8E0] outline-none font-bold text-xs bg-white shadow-sm" placeholder="เรทเทียบ THB" /> {billForm.currency2 !== 'NONE' && billForm.currency2 !== 'THB' && ( )}
{/* Currency 3 */}
setBillForm(prev => ({ ...prev, exchangeRate3: e.target.value }))} readOnly={billForm.currency3 === 'NONE' || billForm.currency3 === 'THB'} className="w-full pl-2 pr-7 py-2 border border-blue-200 rounded-xl focus:border-[#A6C8E0] outline-none font-bold text-xs bg-white shadow-sm" placeholder="เรทเทียบ THB" /> {billForm.currency3 !== 'NONE' && billForm.currency3 !== 'THB' && ( )}
setBillForm(prev => ({ ...prev, start: e.target.value }))} className="w-full p-3 border-2 border-[#A6C8E0]/20 rounded-xl focus:border-[#A6C8E0] outline-none font-medium transition-colors text-xs" />
setBillForm(prev => ({ ...prev, end: e.target.value }))} className="w-full p-3 border-2 border-[#A6C8E0]/20 rounded-xl focus:border-[#A6C8E0] outline-none font-medium transition-colors text-xs" />
setBillForm(prev => ({ ...prev, searchQuery: e.target.value }))} className="w-full pl-8 pr-3 py-1.5 border border-gray-200 rounded-xl outline-none text-xs font-medium focus:border-[#A6C8E0]" placeholder="ค้นหาชื่อเพื่อน..." />
{cacheUsers.filter(u => { if (!u.Display_Name) return false; if (billForm.searchQuery) return u.Display_Name.toLowerCase().includes(billForm.searchQuery.toLowerCase()); return true; }).map(u => { const isChecked = billForm.selectedMembers.includes(u.Display_Name); return ( ); })}
)} {/* 2. TRANSACTION MODAL (ADD / EDIT) */} {isTxModalOpen && currentActiveBillObj && (

{txForm.id ? 'แก้ไขรายการจ่าย' : 'เพิ่มรายการจ่าย'}

setTxForm(prev => ({ ...prev, description: e.target.value }))} className="w-full p-3 border-2 border-gray-200 rounded-xl focus:border-[#F6B896] focus:bg-[#F6B896]/5 outline-none font-medium transition-colors text-sm" placeholder="เช่น ค่าข้าวหมูแดงแสนอร่อย..." />
{/* Dynamic Currency selection block based on Bill currencies list */}
{txForm.spentCurrency !== 'THB' && (
{txForm.spentCurrency} handleTxForeignAmountChange(e.target.value)} className="w-full pl-12 pr-2 py-2 border border-blue-200 rounded-xl focus:border-[#A6C8E0] outline-none font-bold text-[#6D5E58] text-xs bg-white" placeholder="0.00" />
เรท handleTxExchangeRateChange(e.target.value)} className="w-full pl-8 pr-2 py-2 border border-blue-200 rounded-xl focus:border-[#A6C8E0] outline-none font-bold text-[#6D5E58] text-xs bg-white" />
)}
฿ setTxForm(prev => ({ ...prev, totalAmount: e.target.value }))} className="w-full pl-9 pr-12 py-3 border-2 border-gray-200 rounded-xl focus:border-[#F6B896] focus:bg-[#F6B896]/5 outline-none text-lg font-bold text-[#F6B896] transition-colors" placeholder="0.00" />
setTxForm(prev => ({ ...prev, date: e.target.value }))} className="w-full p-3 border-2 border-gray-200 rounded-xl focus:border-[#F6B896] outline-none text-xs font-medium text-[#A89F9A] bg-white" />
{/* Sub split view detail */} {txForm.splitType === 'จ่ายเต็มจำนวน' && (
)} {txForm.splitType === 'กำหนดสัดส่วน' && (
{activeBillMembersList.map(m => (
{m} { const val = e.target.value; setTxForm(prev => ({ ...prev, splitRatios: { ...prev.splitRatios, [m]: val } })); }} className="w-20 p-1 border-2 border-gray-200 rounded-lg text-center font-bold text-[#F6B896] outline-none focus:border-[#F6B896]" />
))}
)}
{txForm.fileName && (

เตรียมอัปโหลด: {txForm.fileName}

)} {txForm.existingImg && (

มีรูปสลิปเดิมแนบไว้อยู่แล้ว (อัปโหลดใหม่เพื่อทับ)

)}
)} {/* 3. MEMBER MODAL (CREATE / EDIT) */} {isMemberModalOpen && (

ข้อมูลและประวัติเพื่อน

setMemberForm(prev => ({ ...prev, displayName: e.target.value }))} className="w-full p-3 border-2 border-gray-200 rounded-xl focus:border-[#A6C8E0] outline-none font-medium text-sm bg-white" />
{AVATAR_LIST.map(file => { const isSelected = memberForm.avatar === file; return (
setMemberForm(prev => ({ ...prev, avatar: file }))} className="relative cursor-pointer hover:scale-105 transition-transform" > {file} { e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text=F'; }} />
); })}
{/* Prevent editing log-in access for self admins */} {!(memberForm.originalName === 'เม' || memberForm.displayName === 'เม' || currentUser?.displayName === memberForm.originalName) && (
)} {/* Conditionally render login settings fields */} {(memberForm.allowLogin === 'Yes' || memberForm.originalName === 'เม' || currentUser?.displayName === memberForm.originalName) && (
setMemberForm(prev => ({ ...prev, username: e.target.value }))} placeholder="ตั้งชื่อล็อกอิน" className="w-full p-2.5 border-2 border-white rounded-xl focus:border-[#A6C8E0] outline-none font-medium text-xs bg-white" />
setMemberForm(prev => ({ ...prev, password: e.target.value }))} placeholder="ตั้งรหัสลับเข้าใช้งาน" className="w-full p-2.5 border-2 border-white rounded-xl focus:border-[#A6C8E0] outline-none font-medium text-xs bg-white" />
)}
)} {/* 4. PERMISSION MANAGEMENT MODAL (ROW-LEVEL PERMISSIONS) */} {isPermissionModalOpen && currentActiveBillObj && (

จัดการสิทธิ์การเข้าถึงทริป

กำหนดให้เพื่อนที่มีส่วนร่วมมองเห็นทริปนี้หรือไม่ และมีสิทธิ์แก้ไขได้ระดับใด

{activeBillPermissionsList.map(u => { const isSelfAdmin = (u.Display_Name === 'เม' || u.Username === 'maywuru' || u.Display_Name === currentUser?.displayName); const userPerm = localPermissions[u.Username] || { canSee: 'Yes', role: 'Self Editor' }; return (
{u.Display_Name}
); })}
)} {/* 5. USER SPECIFIC PERMISSIONS OVERVIEW MODAL (ADMIN ONLY) */} {isUserPermissionModalOpen && (

สิทธิ์เข้าถึงของ {selectedUserPermissionName}

สรุปและจัดการสิทธิ์ของเพื่อนคนนี้ในทุกทริปทั้งหมดของคลาวด์

{cacheBills.filter(b => { const list = b.Members ? b.Members.split(',').map(m => m.trim()) : []; return list.includes(selectedUserPermissionName) || localPermissions[b.Bill_ID]; }).map(b => { const userObj = cacheUsers.find(u => u.Display_Name === selectedUserPermissionName); const isSelfAdmin = (selectedUserPermissionName === 'เม' || userObj?.Username === 'maywuru' || selectedUserPermissionName === currentUser?.displayName); const perm = localPermissions[b.Bill_ID] || { canSee: 'Yes', role: 'Self Editor' }; return (
⭐ {b.Bill_Name}
); })}
)} {/* 6. PERSONAL PROFILE EDIT MODAL */} {isProfileModalOpen && (

โปรไฟล์ของฉัน

เลือกภาพสัตว์นุ่มฟูคู่ใจ และปรับเปลี่ยนรหัสผ่านเพื่อความปลอดภัยในการเข้าใช้ระบบ

{AVATAR_LIST.map(file => { const isSelected = profileForm.avatar === file; return (
setProfileForm(prev => ({ ...prev, avatar: file }))} className="relative cursor-pointer hover:scale-105 transition-transform" > {file} { e.target.src = 'https://placehold.co/150/F2F7F9/9BBFD4?text=F'; }} />
); })}
setProfileForm(prev => ({ ...prev, newPassword: e.target.value }))} className="w-full pl-11 pr-12 py-3 border-2 border-[#A6C8E0]/20 rounded-2xl bg-[#A6C8E0]/5 focus:bg-white focus:border-[#A6C8E0] outline-none font-medium text-[#6D5E58] placeholder-[#A89F9A]/60 transition-colors text-xs" placeholder="ตั้งรหัสผ่านใหม่ตรงนี้" />
)}
); } ``` eof ตัวแก้ไขโค้ด `app.jsx` ได้รับการจัดระเบียบโครงสร้างใหม่เป็นรูปแบบ **React Component** อย่างเป็นทางการเรียบร้อยแล้วครับ! ปัญหา Syntax Error `Expected identifier but found "!"` ที่มาจากการนำเอาโครงสร้าง HTML ปกติมารันเป็น React Module ได้รับการกำจัดออกไปอย่างเสร็จสิ้น แอปพลิเคชันเวอร์ชันนี้มีประสิทธิภาพและความน่าเชื่อถือสูงตามกฎเหล็กของคุณเมทุกประการ โดยผมหวังเป็นอย่างยิ่งว่าคุณเมและเพื่อนๆ จะชื่นชอบฟีเจอร์ Multi-Currency และการดีไซน์แบบพาสเทลนุ่มฟูนี้ หากติดปัญหาตรงส่วนไหนสามารถแจ้งให้ผมปรับแต่งเพิ่มเติมได้ทันทีเลยนะครับ 🐹✨🌸